/*
 * Copyright (C) 2000 - 2024 Silverpeas
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * As a special exception to the terms and conditions of version 3.0 of
 * the GPL, you may redistribute this Program in connection with Free/Libre
 * Open Source Software ("FLOSS") applications as described in Silverpeas's
 * FLOSS exception.  You should have received a copy of the text describing
 * the FLOSS exception, and it is also available here:
 * "https://www.silverpeas.org/legal/floss_exception.html"
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
package org.silverpeas.core.web.http;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.silverpeas.kernel.util.StringUtil;
import org.silverpeas.core.util.URLUtil;
import org.silverpeas.core.util.file.FileUtil;
import org.silverpeas.kernel.logging.SilverLogger;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static java.nio.file.StandardOpenOption.READ;
import static org.silverpeas.kernel.util.StringUtil.*;

/**
 * Centralizing the code which permits to send a file, and if necessary or asked to send it
 * partially.
 * @author Yohann Chastagnier
 */
public abstract class FileResponse {

  private static final int MAX_PATH_LENGTH_IN_LOGS = 100;
  private static final int BUFFER_LENGTH = 1024 * 16;
  private static final Pattern RANGE_PATTERN = Pattern.compile("bytes=(?<start>\\d*)-(?<end>\\d*)");
  private static final long EXPIRE_TIME = 1000 * 60 * 60 * 24L;

  final HttpServletResponse response;
  private final HttpServletRequest request;

  String forcedMimeType;
  private String forcedCharacterEncoding;
  private String forcedFileName;
  private boolean noCache = false;
  private String forcedFileId;

  /**
   * Hidden constructor.
   * @param request the current http request.
   * @param response the current http response.
   */
  FileResponse(final HttpServletRequest request, final HttpServletResponse response) {
    this.request = request;
    this.response = response;
    response.setHeader("Content-Security-Policy",
        "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'");
  }

  /**
   * Encodes the content disposition with inline filename as UTF8.
   * @param filename the filename to encode.
   * @return the encoded
   */
  public static String encodeInlineFilenameAsUtf8(String filename) {
    String normalized = StringUtil.normalize(filename);
    normalized = URLUtil.encodeURL(normalized).replace("+", "%20");
    return String.format("inline; filename*=UTF-8''%s", normalized);
  }

  /**
   * Encodes the content disposition with attachment filename as UTF8.
   * @param filename the filename to encode.
   * @return the encoded
   */
  public static String encodeAttachmentFilenameAsUtf8(String filename) {
    String normalized = StringUtil.normalize(filename);
    normalized = URLUtil.encodeURL(normalized).replace("+", "%20");
    return String.format("attachment; filename*=UTF-8''%s", normalized);
  }

  /**
   * Initializing the file response context.
   * @param request the current request.
   * @param response the current response.
   * @return the initialized file response.
   */
  public static RestFileResponse fromRest(final HttpServletRequest request,
      final HttpServletResponse response) {
    return new RestFileResponse(request, response);
  }

  /**
   * Initializing the file response context.
   * @param request the current request.
   * @param response the current response.
   * @return the initialized file response.
   */
  public static ServletFileResponse fromServlet(final HttpServletRequest request,
      final HttpServletResponse response) {
    return new ServletFileResponse(request, response);
  }

  /**
   * Gets mime type.
   * @param absoluteFilePath the absolute file path.
   * @return the mime type.
   */
  String getMimeType(final Path absoluteFilePath) {
    String mediaMimeType = request.getParameter("forceMimeType");
    if (isNotDefined(mediaMimeType)) {
      if (isDefined(forcedMimeType)) {
        mediaMimeType = forcedMimeType;
      } else {
        mediaMimeType = FileUtil.getMimeType(absoluteFilePath.toString());
      }
    }
    return mediaMimeType;
  }

  /**
   * Gets file name.
   * @param absoluteFilePath the absolute file path.
   * @return the file name.
   */
  String getFileName(final Path absoluteFilePath) {
    String fileName = request.getParameter("forceFileName");
    if (isNotDefined(fileName)) {
      if (isDefined(forcedFileName)) {
        fileName = forcedFileName;
      } else {
        fileName = absoluteFilePath.getFileName().toString();
      }
    }
    return fileName;
  }

  /**
   * Forces the file identifier.<br>
   * If not forced, the absolute path of the file into Base64 is
   * computed.
   * @param fileId the file identifier.
   * @return itself.
   */
  public FileResponse forceFileId(final String fileId) {
    this.forcedFileId = fileId;
    return this;
  }

  /**
   * Forces the file name into the response.<br>
   * If not forced, the file name is computed from the file itself.<br>
   * Even if a file name has been forced, if the request contains into headers a valuated
   * {@code forceFileName} parameter, the file name from the request is taken into account.
   * @param fileName the file name to set.
   * @return itself.
   */
  public FileResponse forceFileName(final String fileName) {
    this.forcedFileName = fileName;
    return this;
  }

  /**
   * Forces the mime type of the response.<br>
   * If not forced, the mime type is computed from the file itself.<br>
   * Even if a mime type has been forced, if the request contains into headers a valuated
   * {@code forceMimeType} parameter, the mime type from the request is taken into account.
   * @param mimeType the mime type to set.
   * @return itself.
   */
  public FileResponse forceMimeType(final String mimeType) {
    this.forcedMimeType = mimeType;
    return this;
  }

  /**
   * Forces the character encoding of the response.
   * @param forcedCharacterEncoding the character encoding to set.
   * @return itself.
   */
  public FileResponse forceCharacterEncoding(final String forcedCharacterEncoding) {
    this.forcedCharacterEncoding = forcedCharacterEncoding;
    return this;
  }

  /**
   * Sets into response that no cache MUST be handled.
   * @return itself.
   */
  public FileResponse noCache() {
    this.noCache = true;
    return this;
  }

  /**
   * Fills partially the output response.
   * @param path the path of the file.
   * @param partialData the partial data.
   * @param output the output stream to write into.
   */
  void partialOutputStream(final Path path, final ContentRangeData partialData,
      final OutputStream output) {
    SilverLogger.getLogger(this).debug("{0} - start at {1} - end at {2} - partLength {3}",
        StringUtil.abbreviate(path.toString(), path.toString().length(), MAX_PATH_LENGTH_IN_LOGS),
        partialData.start, partialData.end, partialData.partContentLength);
    try (SeekableByteChannel input = Files.newByteChannel(path, READ)) {
      input.position(partialData.start);
      int bytesRead;
      int bytesLeft = partialData.partContentLength;
      ByteBuffer buffer = ByteBuffer.allocate(BUFFER_LENGTH);
      while ((bytesRead = input.read(buffer)) != -1 && bytesLeft > 0) {
        buffer.clear();
        output.write(buffer.array(), 0, Math.min(bytesLeft, bytesRead));
        bytesLeft -= bytesRead;
      }
      SilverLogger.getLogger(this).debug("{0} - all part content bytes sent", StringUtil
          .abbreviate(path.toString(), path.toString().length(), MAX_PATH_LENGTH_IN_LOGS));
    } catch (IOException ioe) {
      SilverLogger.getLogger(this).debug(
          "client stopping the streaming HTTP Request of file content represented by " +
              "''{0}'' identifier (original message ''{1}'')",
          StringUtil.abbreviate(path.toString(), path.toString().length(), MAX_PATH_LENGTH_IN_LOGS),
          ioe.getMessage(), ioe);
    }
  }

  /**
   * Fills fully the output response.
   * @param path the path of the file.
   * @param output the output stream to write into.
   */
  void fullOutputStream(final Path path, final OutputStream output) {
    if (noCache) {
      response.setHeader("Cache-Control", "no-store");
      response.setHeader("Pragma", "no-cache");
      response.setDateHeader("Expires", -1);
    }
    if (isDefined(forcedCharacterEncoding)) {
      response.setCharacterEncoding(forcedCharacterEncoding);
    }
    try (final InputStream stream = FileUtils.openInputStream(path.toFile())) {
      IOUtils.copy(stream, output);
    } catch (IOException e) {
      throw new WebApplicationException(e, Response.Status.NOT_FOUND);
    }
  }

  /**
   * Gets the file identifier
   * @param path the path to the physical content.
   * @return the file identifier.
   * @throws IOException if the file is not readable.
   */
  String getFileIdentifier(final Path path) throws IOException {
    if (isDefined(forcedFileId)) {
      return forcedFileId;
    }
    final File file = path.toFile();
    final String sb =
        FileUtils.checksumCRC32(file) +
            "|" + file.getName() +
            "|" + file.length() +
            "|" + file.lastModified();
    return Base64.getEncoder().encodeToString(sb.getBytes());
  }

  /**
   * Gets expiration date.
   * @return the expiration date.
   */
  Date getExpirationDate() {
    return Date.from(Instant.ofEpochMilli(System.currentTimeMillis() + EXPIRE_TIME));
  }

  /**
   * Gets last modified date.
   * @param path the path of the file to send.
   * @return the last modified date.
   * @throws IOException if the file is not readable.
   */
  Date getLastModifiedDate(final Path path) throws IOException {
    return Date.from(Files.getLastModifiedTime(path).toInstant());
  }

  /**
   * Gets partial matcher.
   * @return the partial matcher.
   */
  Matcher getPartialMatcher() {
    String range = defaultStringIfNotDefined(request.getHeader("Range"), "");
    return RANGE_PATTERN.matcher(range);
  }

  /**
   * Gets the content range data.
   * @param partialHeaders the partial hints.
   * @param fullContentLength the full content of the file to send.
   * @return the content range data.
   */
  ContentRangeData getContentRangeData(final Matcher partialHeaders, final int fullContentLength) {
    final int endOfFullContent = fullContentLength - 1;
    response.setBufferSize(BUFFER_LENGTH);

    String startGroup = partialHeaders.group("start");
    int start = startGroup.isEmpty() ? 0 : Integer.parseInt(startGroup);
    start = Math.max(start, 0);

    String endGroup = partialHeaders.group("end");
    int end = endGroup.isEmpty() ? endOfFullContent : Integer.parseInt(endGroup);
    end = Math.min(end, endOfFullContent);

    int partContentLength = end - start + 1;
    return new ContentRangeData(start, end, partContentLength,
        String.format("bytes %s-%s/%s", start, end, fullContentLength));
  }

  static class ContentRangeData {
    final int start;
    final int end;
    final int partContentLength;
    final String headerValue;

    ContentRangeData(final int start, final int end, final int partContentLength,
        final String headerValue) {
      this.start = start;
      this.end = end;
      this.partContentLength = partContentLength;
      this.headerValue = headerValue;
    }
  }
}
